Skip to content

chore: sampler serialization tests - #250

Open
selmanozleyen wants to merge 5 commits into
scverse:mainfrom
selmanozleyen:chore/sampler-serialization-tests
Open

chore: sampler serialization tests#250
selmanozleyen wants to merge 5 commits into
scverse:mainfrom
selmanozleyen:chore/sampler-serialization-tests

Conversation

@selmanozleyen

@selmanozleyen selmanozleyen commented Jul 9, 2026

Copy link
Copy Markdown
Member

These tests ensure that the sampler and their rng states are serializable. This will also help us catch if any changes we make to the samplers keep them serializable or not. This will be more useful in my following PR's when I might add classes that might test serialization assumption

@selmanozleyen selmanozleyen added the skip-gpu-ci Whether gpu ci should be skipped label Jul 9, 2026
@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.29630% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 91.76%. Comparing base (56127d5) to head (d569be8).

Files with missing lines Patch % Lines
src/annbatch/abc/sampler.py 96.29% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #250      +/-   ##
==========================================
- Coverage   93.43%   91.76%   -1.67%     
==========================================
  Files          15       15              
  Lines        1432     1458      +26     
==========================================
  Hits         1338     1338              
- Misses         94      120      +26     
Files with missing lines Coverage Δ
src/annbatch/abc/sampler.py 97.36% <96.29%> (-0.64%) ⬇️

... and 5 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@selmanozleyen
selmanozleyen requested a review from ilan-gold July 9, 2026 23:05
Comment thread tests/test_sampler.py Outdated
@ilan-gold

Copy link
Copy Markdown
Collaborator

Sorry, one other thing, we also want to make sure that simply making a copy of the samplers works. This is different than pickling

@selmanozleyen
selmanozleyen requested a review from ilan-gold July 14, 2026 12:54
Comment thread tests/test_sampler.py Outdated

def advance_round_trip_indices(seed: int) -> list[int]:
sampler = _make_sampler(kind, seed, n_obs)
collect_indices(sampler, n_obs) # advance the rng one pass

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to advance the rng first here and below?

@selmanozleyen selmanozleyen Jul 14, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because this also tests randomness. For example maybe we override the rng to always to np.default(0). The tests would still pass otherwise.

I mean we already test randomness somewhere else but I wrote it in the very small chance that the seed I set and whatever seed could be set in the case of such a faulty override would be the same

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't follow - if you provide a seed explicitly, why does it matter how many times you advance state before/after making a copy?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah you are right sorry.

Comment thread tests/test_sampler.py Outdated
Comment on lines +954 to +955
assert restored_indices == advance_round_trip_indices(seed=0)
assert restored_indices != advance_round_trip_indices(seed=1)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this testing?

@ilan-gold ilan-gold left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://docs.python.org/3/library/copy.html#object.__copy__ + https://docs.python.org/3/library/copy.html#object.__deepcopy__

In order to ensure we aren't doing anything funny, it would be probably best to implement __copy__, __deepcopy__ and __eq__ methods on our samplers.

@selmanozleyen
selmanozleyen requested a review from ilan-gold July 15, 2026 13:23

@ilan-gold ilan-gold left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about #250 (review)?

@selmanozleyen

Copy link
Copy Markdown
Member Author

What about #250 (review)?

Did you mean to link this:

What is this testing?

it guards against an ignored/hard-coded rng. Otherwise the tests would still pass

@selmanozleyen

Copy link
Copy Markdown
Member Author

https://docs.python.org/3/library/copy.html#object.__copy__ + https://docs.python.org/3/library/copy.html#object.__deepcopy__

In order to ensure we aren't doing anything funny, it would be probably best to implement copy, deepcopy and eq methods on our samplers

Why? If our attributes don't support copy they would fail anyway. Maybe __eq__ would make sense because you'd define the semantics of equality but I don't see how that would be useful. In the end the implementation would be to recursively copy our attributes but thats already the default implementation

@ilan-gold

Copy link
Copy Markdown
Collaborator

As discussed yesterday, error on __copy__, implement __eq__ and __deepcopy__

@selmanozleyen

Copy link
Copy Markdown
Member Author

The problem with __eq__ is pandas and other structures have their own equals. I did it the hacky way with checking of attribute of equals. Because I didn't want to write every pandas type.

By default python would've compare references. So it would be a stricter check, even though the current function would give somewhat better sense of structural equality if a subclass has an attribute that doesn't implement __eq__, and they forget to override __eq__ on that subclass (which is very likely), they will experience inconsistent behaviour where some codepaths depend on structural-__eq__ for their logic.

To avoid it we'd need

def _attr_equal(a: object, b: object) -> bool:
    if isinstance(a, np.random.Generator) or isinstance(b, np.random.Generator):
        return (isinstance(a, np.random.Generator) and isinstance(b, np.random.Generator)
                and a.bit_generator.state == b.bit_generator.state)
    if isinstance(a, Sampler) or isinstance(b, Sampler):
        return a == b
    if isinstance(a, np.ndarray) or isinstance(b, np.ndarray):
        return isinstance(a, np.ndarray) and isinstance(b, np.ndarray) and bool(np.array_equal(a, b))
    if hasattr(a, "equals") and hasattr(b, "equals") and type(a) is type(b):   # pandas
        return bool(a.equals(b))
    if isinstance(a, int | float | bool | str | bytes | slice | tuple | frozenset | type(None)):
        return type(a) is type(b) and bool(a == b)
    raise TypeError(
        f"Sampler equality doesn't know how to compare {type(a).__name__!r} state. "
        "Extend _attr_equal or override __eq__ on the subclass that added this attribute."
    )

But then this would fail only when __eq__ of unsupported class is being used. Which might be also confusing

@ilan-gold ilan-gold left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry if this was not clear, I thought we had discussed this in-person

_mask: slice = slice(0, None)
_rng: np.random.Generator | None = None

def __eq__(self, other: object) -> bool:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this got lost in translation, but I think this should just be implemented custom per-implementation (so you don't have to handle every case as above and what constitutes "equal" is clear for every individual Sampler) - for now, you can make it an optional overload, but warn that in the future, it will become part of the abstract methods required

"Use copy.deepcopy() instead."
)

def __deepcopy__(self, memo: dict[int, Any]) -> Self:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thing as __eq__ here

@flying-sheep flying-sheep left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think documenting that the default __eq__ supports numpy, pandas, and Generators is also sufficient, no?

Could do a bit more work though, so e.g. comparing Sampler(f=pd.array(...)) == Sampler(f=np.array(...)) returns False instead of crashing.

Comment on lines +40 to +41
if hasattr(a, "equals") and hasattr(b, "equals") and type(a) is type(b):
return bool(a.equals(b))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe you mean this? otherwise, comparing two different ExtensionArrays will crash.

Suggested change
if hasattr(a, "equals") and hasattr(b, "equals") and type(a) is type(b):
return bool(a.equals(b))
if hasattr(a, "equals") and hasattr(b, "equals"):
return type(a) is type(b) and bool(a.equals(b))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function was one of the reasons I suggested moving to a per-class model: #250 (comment) It will make comparisons of the constituent parts much clearer, at this risk of the occasional repeated line of code.

@ilan-gold

Copy link
Copy Markdown
Collaborator

I think documenting that the default eq supports numpy, pandas, and Generators is also sufficient, no?

Right this would be the other option to what I'm describing. Add a dumb default, document it, and tell people to override if they need. But blindly taking everything from __dict__ seems error-prone

@selmanozleyen

selmanozleyen commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

First of all, nice to see you back :)

One thing I'd like to separate: why do we care about __eq__ in this case? Can't we let it compare the references? Which is more strict anyway?

About the rest I still have questions ,

Maybe this got lost in translation, but I think this should just be implemented custom per-implementation (so you don't have to handle every case as above and what constitutes "equal" is clear for every individual Sampler) - for now, you can make it an optional overload, but warn that in the future, it will become part of the abstract methods required

Okay, I can do that ofc. But again if someone inherits those classes it will break silently (unless we make deepcopy uninheritable for child classes somehow, but do we really want that)?

But blindly taking everything from dict seems error-prone

It is for sure. But I can't think of an unproblematic way to do this.

The best you can do is probably this: we have a registry of classes in which we support and guarantee (ie deepcopyable classes) (it should be a list of classes, the other solution is to have a list of attributes which is super unusual and probably more error prone as well). Under __init_subclass__ we do a check for each field if we recognize it or not. Then give an error telling that class should be recognized somehow explicitly. This also has problems, what if a field is not initialized yet? are we going to do this only by typing hints? etc. etc. Seems like a fight against the unserious nature of python.

Why isn't documenting and proper unit tests not enough here to ensure our guarantees? Like if the pickled bin files are byte identical of samplers, what more can we guarantee?

@ilan-gold

Copy link
Copy Markdown
Collaborator

Okay, I can do that ofc. But again if someone inherits those classes it will break silently (unless we make deepcopy uninheritable for child classes somehow, but do we really want that)?

That was implied yeah, I would be fine making it an abstract method or raising NotImplementedError by default but also having a clearly defined minimal fallback would be fine. I would lean towards the former i.e., forcing people to implement it.

First of all, why do we care about eq in this case? Can't we let it compare the references?

I want to be 100% sure that sampler_1 == sampler_2 does exactly what we want it to, and not something secretly different. For example maybe we want Sampler(...params, gpu_accelerate) == Sampler(...same_params, not_gpu_accelerate) to be True - just a contrived example, but highlights why I'd rather this be explicit.

But I can't think of an unproblematic way to do this.

Just enforcing that people have to implement it?

Why isn't documenting and proper unit tests not enough here to ensure our guarantees?

See above, my idea was that "two samplers are equal if the produce the same iteration output" which is the same as "do they have the same relevant state" but not "do they have the same state completely." Kind of like how np.array(some_number, dtype=np.int32) == np.array(some_number, dtype=np.int64) so I think being explicit about this is good.

@selmanozleyen

Copy link
Copy Markdown
Member Author

That was implied yeah, I would be fine making it an abstract method or raising NotImplementedError by default but also having a clearly defined minimal fallback would be fine. I would lean towards the former i.e., forcing people to implement it.

Sorry I should've been more clear, I meant preventing from inheriting a Sampler's child class''s implementation of deepcopy or __eq__. Do we want that? For example, if someone wants to inherit RandomSampler, should they be required to implement it as well?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-gpu-ci Whether gpu ci should be skipped

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants